The WPF layout system is extremely powerful, there’s almost nothing you can’t do with Grid and maybe a few DockPanel objects – but that power comes at a price and that price is a lot of typing.
I find it hard to believe that anyone who has written any form in WPF isn’t sick of <RowDefinition Height="Auto"/> and Grid.Column=”1” Grid.Row=”1” – and of course things get worse when you have to add a new row at the beginning of the form and you have to manually update all those Grid.Row definitions.
So, in this series I will try to solve the problem.
Now it’s important to remember we are trying to simplify our code here, we will not write a powerful do-everything control, we will write something that will cover the simple cases (that are around 80% of cases) and the rest we will code with Grid.
This series has 3 parts:
- Easy form layout in WPF Part 1 – Introducing FormPanel (You are here).
- Easy form layout in WPF Part 2 – How to deal with more complicated scenarios.
- Easy form layout in WPF Part 3 – Adding Groups.
You can find the complete source code with a sample project at the end of the last post.
Let’s start from the desired end result and work back to the code:
I’m writing a bug tracking product and I want a form with the usual bug tracking fields:
I want all the labels to be the same size and all the text boxes and combo boxes to be the same size, I want the labels centered vertically and the text/combo boxes to fill all the available width, I want constant spacing between labels and controls and between row and columns.
And most of all I want everything without writing any code or XAML on every windows.
This is what I want the XAML for the window above to look like (a real app would obviously need some data binding code to fill the controls):
<Window x:Class="FormPanelApp.Window3"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:l="clr-namespace:FormPanelApp"
Title="Window3" Height="300" Width="500">
<l:FormPanel Margin="10">
<TextBlock Text="Title:"/>
<TextBox/>
<TextBlock Text="Area:"/>
<ComboBox/>
<TextBlock Text="Category:"/>
<ComboBox/>
<TextBlock Text="Assigned To:"/>
<ComboBox/>
<TextBlock Text="Status:"/>
<ComboBox/>
<TextBlock Text="Estimate:"/>
<TextBox/>
<TextBlock Text="Tags:"/>
<TextBox/>
<TextBlock Text="Version:"/>
<TextBox/>
</l:FormPanel>
</Window>
So what am I going to do? easy, write a custom panel.
We will Create a FormPanel class that inherits from the WPF Panel.
using System;
using System.Windows.Controls;
using System.Windows;
namespace FormPanelApp
{
public class FormPanel : Panel
{
Now we will add some dependency properties for things we would like to be configurable in the panel.
The first and most important is the number of columns (each column is a label/control pair):
public static readonly DependencyProperty ColumnsProperty =
DependencyProperty.Register("Columns", typeof(int), typeof(FormPanel),
new FrameworkPropertyMetadata(2, FrameworkPropertyMetadataOptions.AffectsMeasure));
public int Columns
{
get { return (int)GetValue(ColumnsProperty); }
set { SetValue(ColumnsProperty, value); }
}
The spacing between rows and columns:
public static readonly DependencyProperty ColumnSpacingProperty =
DependencyProperty.Register("ColumnSpacing", typeof(double), typeof(FormPanel),
new FrameworkPropertyMetadata(15.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
public double ColumnSpacing
{
get { return (double)GetValue(ColumnSpacingProperty); }
set { SetValue(ColumnSpacingProperty, value); }
}
public static readonly DependencyProperty RowSpacingProperty =
DependencyProperty.Register("RowSpacing", typeof(double), typeof(FormPanel),
new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
public double RowSpacing
{
get { return (double)GetValue(RowSpacingProperty); }
set { SetValue(RowSpacingProperty, value); }
}
And the space between labels and controls:
public static readonly DependencyProperty LabelControlSpacingProperty =
DependencyProperty.Register("LabelControlSpacing", typeof(double), typeof(FormPanel),
new FrameworkPropertyMetadata(5.0, FrameworkPropertyMetadataOptions.AffectsMeasure));
public double LabelControlSpacing
{
get { return (double)GetValue(LabelControlSpacingProperty); }
set { SetValue(LabelControlSpacingProperty, value); }
}
We will also create dependency properties for the size of labels and controls as calculated by the panel, in the next post we will see how useful this will be:
public static readonly DependencyProperty LabelSizeProperty =
DependencyProperty.Register("LabelSize", typeof(Size), typeof(FormPanel));
public Size LabelSize
{
get { return (Size)GetValue(LabelSizeProperty); }
set { SetValue(LabelSizeProperty, value); }
}
public static readonly DependencyProperty ControlSizeProperty =
DependencyProperty.Register("ControlSize",typeof(Size),typeof(FormPanel));
public Size ControlSize
{
get { return (Size)GetValue(ControlSizeProperty); }
set { SetValue(ControlSizeProperty, value); }
}
Now I'm going to add something called a coordinator, in the third post we will use it to quickly make the FormPanel even more useful - you can ignore it for now:
public IFormPanelCoordinator Coordinator { get; set; }
All the actual work in a panel is done in two methods: MeasureOverride and ArrangeOverride.
The MeasureOverride method calculates the required size for the panel, in our case we just scan all the panel's children and look for the maximum label width and height and maximum control width and height.
We save the results in the LabelSize and ControlSize properties we defined earlier and calculate the required size based on those sizes, the number of columns and the spacing properties we defined.
protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize)
{
double labelMaxWidth = 0;
double labelMaxHeight = 0;
double controlMaxWidth = 0;
double controlMaxHeight = 0;
for (int i = 0; i < Children.Count-1; i += 2)
{
Children[i].Measure(availableSize);
Children[i + 1].Measure(availableSize);
labelMaxWidth = Math.Max(labelMaxWidth, Children[i].DesiredSize.Width);
labelMaxHeight = Math.Max(labelMaxHeight, Children[i].DesiredSize.Height);
controlMaxWidth = Math.Max(controlMaxWidth, Children[i+1].DesiredSize.Width);
controlMaxHeight = Math.Max(controlMaxHeight, Children[i+1].DesiredSize.Height);
}
var oldLabelSize = LabelSize;
var oldControlSize = ControlSize;
var newLabelSize = new Size(labelMaxWidth, labelMaxHeight);
var newControlSize = new Size(controlMaxWidth, controlMaxHeight);
LabelSize = newLabelSize;
ControlSize = newControlSize;
if (Coordinator != null &&
(newLabelSize != oldLabelSize || newControlSize != oldControlSize))
{
Coordinator.ControlOrLabelSizeChanged(this);
}
return new Size(
Columns * (LabelSize.Width + ControlSize.Width + LabelControlSpacing) + (Columns - 1) * ColumnSpacing,
((Children.Count/2) / Columns) * Math.Max(LabelSize.Height, ControlSize.Height) + (((Children.Count/2) / Columns) - 1) * RowSpacing);
}
We also notify the coordinator if the size changed, but we will talk about that in the third post
The ArrangeOverride method actually places all the labels and controls, it just loops over all the panel's children and calculates their final location:
protected override Size ArrangeOverride(Size finalSize)
{
double controlWidth = (finalSize.Width - (Columns - 1) * ColumnSpacing - Columns * (LabelSize.Width + LabelControlSpacing)) / Columns;
double rowHeight = Math.Max(LabelSize.Height, ControlSize.Height) + RowSpacing;
double columnWidth = LabelSize.Width + LabelControlSpacing + controlWidth + ColumnSpacing;
for (int i = 0; i < Children.Count - 1; i += 2)
{
var labelRect = new Rect(
columnWidth * ((i / 2) % Columns), rowHeight * ((i / 2) / Columns),
LabelSize.Width, rowHeight - RowSpacing);
Children[i].Arrange(
new Rect(
labelRect.Left,
labelRect.Top+(labelRect.Height-Children[i].DesiredSize.Height)/2,
Children[i].DesiredSize.Width,Children[i].DesiredSize.Height));
Children[i + 1].Arrange(new Rect(
columnWidth * ((i / 2) % Columns) + LabelSize.Width + LabelControlSpacing, rowHeight * ((i / 2) / Columns),
controlWidth, rowHeight - RowSpacing));
}
return new Size(finalSize.Width, rowHeight * ((Children.Count/2) / Columns + 1));
}
and of course, we have to close the class:
}
}
also, I'm listing the IFormPanelCoordinator interface here because the FormPanel wouldn't compile without it.
public interface IFormPanelCoordinator
{
void ControlOrLabelSizeChanged(FormPanel sender);
}
And, as you see, we wrote a completely trivial class that take care of the annoying task of manually setting the grid layout.
I said in the beginning this is a simple class that will only cover the common cases, in the next post we will see how this class still saves us a lot of typing in the not-so-simple case.
posted @ Tuesday, July 27, 2010 2:27 PM